Skip to content

feat(composer): add rich text editor with formatting and mentions#310

Draft
tellaho wants to merge 29 commits intomainfrom
tho/richtext-editor
Draft

feat(composer): add rich text editor with formatting and mentions#310
tellaho wants to merge 29 commits intomainfrom
tho/richtext-editor

Conversation

@tellaho
Copy link
Copy Markdown
Collaborator

@tellaho tellaho commented Apr 13, 2026

Category: improvement
User Impact: The message composer now supports rich text formatting (bold, italic, lists, quotes, links) with an animated toolbar, inline @mention and #channel highlighting, concurrent file uploads with skeleton previews, and click-to-open image lightboxes for composer attachments.

Problem: The composer was a plain textarea — users couldn't format messages without manually typing Markdown syntax, had no visual feedback for @mentions or #channel references while typing, and file uploads were sequential with no progress indication. Viewing full-size attached images required opening them externally.

Solution: Replace the textarea with a TipTap-based rich text editor that renders formatting live as users type. The toolbar uses a two-state animated design (passive → expanded) to keep the default view clean while making formatting discoverable. @mention and #channel patterns are highlighted inline via a ProseMirror decoration plugin. File uploads now run concurrently with per-file skeleton placeholders that preserve insertion order via a slot-based system. Images in the composer open in a lightbox overlay when their thumbnails are clicked.

File changes

Cargo.toml
Added .worktree/chat-style/desktop/src-tauri to the workspace exclude list to prevent Cargo from picking up worktree build artifacts.

desktop/package.json
Added TipTap editor dependencies (@tiptap/core, @tiptap/react, @tiptap/starter-kit, @tiptap/extension-link, @tiptap/extension-placeholder, @tiptap/pm, tiptap-markdown), the motion animation library, and @radix-ui/react-toggle for the formatting toolbar.

desktop/pnpm-lock.yaml
Lockfile update reflecting the new TipTap, motion, and Radix Toggle dependencies.

desktop/src/features/channels/ui/ChannelPane.tsx
Removed the isSending guard from the composer-disabled check so the composer stays interactive while a message is in flight.

desktop/src/features/messages/lib/useRichTextEditor.ts (new)
Core TipTap editor hook — configures StarterKit, Markdown serialization, link autodetection, placeholder, and custom keyboard shortcuts for list/blockquote exit behavior. Exposes getMarkdown(), setContentWithTrailingSpace() (for autocomplete insertion), getTextAndCursor() (bridges existing mention/channel hooks), and insertImageRef().

desktop/src/features/messages/lib/mentionHighlightExtension.ts (new)
ProseMirror plugin that scans text nodes for @DisplayName and #channel-name patterns and applies inline decorations with a mention-highlight CSS class. Updates reactively when the known names list changes.

desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs (new)
Unit tests for the mention and channel highlight regex patterns, covering exact matches, partial matches, and edge cases.

desktop/src/features/messages/lib/imageRefExtension.ts (new)
Custom TipTap inline atom node (imageRef) that renders uploaded attachments as thumbnail chips in the editor. Serializes to ![hash] markers that the composer resolves to full ![image](url) Markdown on send.

desktop/src/features/messages/lib/useImageRefSuggestions.ts (new)
Suggestion/autocomplete hook for the image ref node — lets users reference previously uploaded attachments by typing a trigger character.

desktop/src/features/messages/lib/useMediaUpload.ts
Rewritten to support concurrent multi-file uploads. Uses a slot-based system (reserveSlots / fillSlot) so files dropped or pasted together maintain their original order regardless of which upload finishes first. Exports shortHash() and ALLOWED_MEDIA_TYPES for reuse.

desktop/src/features/messages/lib/useMediaUpload.test.mjs (new)
Tests for upload slot ordering — verifies that concurrent uploads filling slots out-of-order still produce the correct final attachment sequence.

desktop/src/features/messages/ui/MessageComposer.tsx
Replaced the <textarea> with TipTap <EditorContent>. Wires up the rich text editor hook, formatting state, emoji insertion (now via TipTap commands), drag-and-drop, paste handling, and send logic that resolves imageRef nodes to Markdown before submission.

desktop/src/features/messages/ui/MessageComposerToolbar.tsx
Two-state animated toolbar using motion/react. Passive state shows [@ 📎 😊 Aa]; pressing Aa crossfades to expanded state [Aa ✕ | formatting buttons]. Uses AnimatePresence mode="popLayout" for simultaneous enter/exit animations.

desktop/src/features/messages/ui/FormattingToolbar.tsx (new)
Formatting button row (bold, italic, strikethrough, code, link, bullet list, ordered list, blockquote). Each button reflects active state from TipTap editor state via useEditorState.

desktop/src/features/messages/ui/ComposerAttachments.tsx (new)
Thumbnail strip for uploaded attachments with animated enter/exit, per-file upload skeleton placeholders, click-to-open lightbox preview, and remove buttons.

desktop/src/features/messages/ui/ImageRefAutocomplete.tsx (new)
Autocomplete dropdown for image reference insertion in the editor.

desktop/src/features/messages/ui/ComposerMentionOverlay.tsx (deleted)
Removed — mention highlighting is now handled by the ProseMirror decoration plugin inline in the editor, replacing the old overlay approach.

desktop/src/shared/styles/globals.css
Added ~100 lines of TipTap composer styles — placeholder text, inline formatting (bold, italic, strike, code), block elements (blockquote, lists, code blocks, links, horizontal rules), and the .mention-highlight decoration class.

desktop/src/shared/ui/sidebar.tsx
Changed the sidebar toggle keyboard shortcut from b to s to avoid conflicting with the bold formatting shortcut (⌘B) in the rich text editor.

desktop/src/shared/ui/toggle.tsx (new)
Shared Toggle component (Radix Toggle primitive + Tailwind variants) used by the formatting toolbar buttons.

How to verify

  1. Open any channel and click into the message composer — it should render as a rich text area (not a plain textarea).
  2. Type **bold** or press ⌘B — text should render bold inline.
  3. Click the Aa button in the toolbar — it should animate to show formatting buttons (bold, italic, strike, code, link, lists, quote). Click Aa again to collapse.
  4. Type @ followed by a member name — the mention should highlight with a colored pill in the editor.
  5. Type # followed by a channel name — same highlight treatment.
  6. Drag-and-drop 3+ images onto the composer — each should show an individual skeleton placeholder, and thumbnails should appear in the order they were dropped (not the order uploads finished).
  7. Click any attachment thumbnail in the composer — a lightbox overlay should open with the full-size image.

@tellaho tellaho changed the title feat: Rich text editor with formatting toolbar, image ref chips, and TipTap integration feat: Rich text editor — formatting, media, mentions, and composer polish Apr 14, 2026
@tellaho tellaho changed the title feat: Rich text editor — formatting, media, mentions, and composer polish feat(composer): rich text editor with TipTap, animated toolbar, and media improvements Apr 14, 2026
@tellaho tellaho changed the title feat(composer): rich text editor with TipTap, animated toolbar, and media improvements feat(composer): rich text editor — formatting, mentions, media upload, and toolbar polish Apr 14, 2026
@tellaho tellaho marked this pull request as ready for review April 14, 2026 17:39
@tellaho tellaho requested a review from wesbillman as a code owner April 14, 2026 17:39
@wesbillman
Copy link
Copy Markdown
Collaborator

@codex review please

@tellaho tellaho changed the title feat(composer): rich text editor — formatting, mentions, media upload, and toolbar polish feat(composer): rich text editor with formatting, mentions, and media upload Apr 14, 2026
@tellaho tellaho changed the title feat(composer): rich text editor with formatting, mentions, and media upload feat(composer): add rich text editor with formatting and mentions Apr 14, 2026
@tellaho tellaho marked this pull request as draft April 14, 2026 19:09
tellaho added 19 commits April 14, 2026 11:30
…TipTap integration

Replace plain textarea message composer with TipTap-based rich text editor.

- Add FormattingToolbar with bold, italic, strikethrough, code, and link controls
- Add Toggle UI primitive (shared component)
- Integrate image uploads as context chips with autocomplete suggestions
  (ImageRefAutocomplete, imageRefExtension, useImageRefSuggestions)
- New useRichTextEditor hook encapsulating TipTap setup
- New ComposerAttachments component for uploaded media display
- Remove legacy ComposerMentionOverlay (replaced by TipTap mention/ref system)
- Update MessageComposer and MessageComposerToolbar for new editor
- Keyboard shortcuts: ⌘B bold, ⌘I italic, ⌘K link, ⌘S sidebar toggle
- Update ChannelPane, sidebar, globals.css for layout/style adjustments
- Update dependencies (Cargo.toml, package.json, pnpm-lock.yaml)
Move expanded formatting options (B, I, S, Code, Link, Lists, Quote)
to sit inline in the bottom toolbar right after the Aa toggle, instead
of in a separate row above the editor. File attachment previews remain
as the only element above the textarea.
…[Aa ✕ | formatting]

Passive state shows ingress buttons (mention, attach, emoji) followed by the
Aa formatting toggle. Clicking Aa swaps to expanded mode: Aa toggle, ✕ close
button, separator, then all formatting options. Ingress buttons are hidden
while formatting is open.

Matches the inline-expand design spec from the channel.
…/react

- Aa toggle uses layoutId for smooth slide between positions
- Ingress buttons (@ 📎 😊) fade out + scale down on expand
- Formatting buttons slide in from left + fade in on expand
- ✕ close button scales in alongside Aa
- Reverse animation on collapse
- Uses LayoutGroup + AnimatePresence (popLayout) matching
  existing ComposerAttachments pattern
…bels

- Reset isEmojiPickerOpen when formatting is toggled on (prevents
  emoji picker remounting open on collapse)
- Extract Aa toggle to local variable — single source of truth for
  the layoutId animation across both toolbar states
- Add aria-label to mention button, attach button, and upload spinner
- Pull Aa toggle out of AnimatePresence so it never unmounts —
  layoutId handles the smooth position slide on its own
- Remove React.Fragment wrappers — each branch is a single motion.div
  so AnimatePresence properly tracks enter/exit per element
- popLayout mode pops exiting elements out of flow immediately,
  letting enter animations start at the same time
- Ingress group uses order-[-1] to sit before Aa visually
- No staggering, no sequencing — one fluid concurrent transition
…utocomplete insertion

TipTap's setContent() roundtrips through a markdown parser that strips
trailing whitespace from text nodes. insertContent(' ') and
preserveWhitespace: 'full' also normalise it away.

Use a raw ProseMirror transaction to insert a literal space text node
after setContent, bypassing TipTap's parser entirely. Cursor is placed
after the space via TextSelection so typing continues naturally.

Applied to both applyMentionInsert and applyChannelInsert.
Timeline images now render at max-w-lg (~512px) as clickable thumbnails
with cursor-pointer and a subtle hover opacity transition.

Clicking opens a scrimmed lightbox (bg-black/80) showing the full-res
image at up to 90vh/90vw. Clicking the backdrop or pressing Escape
closes the lightbox. Includes accessible Title and Description for
Radix Dialog, and an explicit close button.

Video rendering left unchanged.
Remove overflow-hidden from the toolbar's layout-animated container.
The overflow clip (added in 50f951c to fix exit ghosting) was hiding
the Aa toggle during intermediate layout frames — when popLayout pops
the exiting group out of flow the container briefly shrinks, and
overflow-hidden clips the layoutId-animated toggle mid-reposition.

Also add layout="position" to the Aa wrapper so Framer Motion only
animates its position, not size, avoiding measurement conflicts with
the simultaneously-resizing parent.

Exit animations (opacity → 0) still look correct without the clip
since the fading elements are the intended visual.
Composer attachment thumbnail chips now open a full-view lightbox when
clicked, reusing the same Radix DialogPrimitive pattern from the
timeline markdown renderer (1ba0dd2). Videos open with native controls.

The existing remove button (X) remains unaffected — the lightbox trigger
wraps only the thumbnail content, not the remove button overlay.

Also bumps the attachment row bottom margin from mb-1 to mb-2 for
better spacing below the chips.
Replace the single boolean upload state with an uploadingCount that
tracks how many files are currently in-flight. Each upload start
increments the count; each completion or error decrements it.
isUploading remains true while count > 0.

handleDrop now processes ALL dropped files concurrently instead of
only files[0]. Each valid file fires off its own upload in parallel.

ComposerAttachments renders uploadingCount skeleton placeholders
(falls back to 1 for backwards compat when count isn't provided).
…attachments

Images now use w-full max-w-xl instead of fixed max-w-lg, so they
flex to their container width (fixes overflow in the 380px thread panel).

When a message contains multiple consecutive images, a custom remark
plugin (remarkImageGallery) groups them into an image-gallery node
that renders as a 2-column CSS grid. Single images remain unchanged.
Use a slot-based approach: reserve null slots in original order when
uploads start, then fill each slot by index as uploads complete.
pendingImeta is derived by filtering nulls, so attachments always
appear in paste/drop order regardless of completion timing.

Also: handlePaste now handles multiple media items from clipboard.
tellaho added 10 commits April 14, 2026 11:31
Extend the existing MentionHighlightExtension to also decorate
#channel-name patterns with the same mention-highlight CSS class.

- Add channelNames to extension storage alongside names
- Build a second regex pattern for #channel matching (same lookbehind
  logic as @mention: must be at start of text or preceded by whitespace)
- Update useRichTextEditor to accept channelNames prop and sync it
  to extension storage in the same useEffect as mentionNames
- Pass channelLinks.knownChannelNames from MessageComposer to the hook
29 tests covering:
- buildHighlightPatterns: empty inputs, mentions-only, channels-only,
  both, regex escaping of special characters
- findHighlightMatches: start-of-text, after whitespace, embedded-in-word
  rejection, case insensitivity, multiple matches, longer-name priority,
  mixed @mention + #channel, empty text/patterns
- shortHash: normal, minimum, empty, short inputs
- Upload slot ordering: reserve/fill, out-of-order concurrent fills,
  removal by URL, padding edge case
…ent render

The Aa toggle was outside AnimatePresence using layoutId to animate
between positions. This caused intermittent disappearance due to
WebKit GPU compositing + Framer Motion layout cache issues.

Restructure: duplicate the Aa toggle inside both state groups
(formatting and ingress) so AnimatePresence handles the crossfade.

Removed: layoutId, LayoutGroup, overflow-hidden, will-change hack,
order-[-1] CSS trick. Kept AnimatePresence mode="popLayout".
markdown.tsx changes moved to tho/timeline-media branch (PR #316).
This PR now focuses solely on composer polish.
… change

- Remove .worktree/chat-style/desktop/src-tauri from Cargo.toml exclude
  (local dev artifact, not part of this PR)
- Revert SIDEBAR_KEYBOARD_SHORTCUT from 's' back to 'b'
  (unrelated to composer polish work)
…ts mid-upload

removeAttachment now nulls out the slot instead of compacting the array,
so in-flight uploads that captured their slot index at reservation time
still write to the correct position. The public pendingImeta view already
filters nulls via useMemo.
The getTextAndCursor function maps ProseMirror positions to plain-text
offsets for the mention/channel autocomplete hooks. The block boundary
handler had an empty body, causing cursor drift of 1 per paragraph
boundary. Now increments offset by 1 for each inter-block newline.
@tellaho tellaho force-pushed the tho/richtext-editor branch from 8971130 to 825d9c1 Compare April 14, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants